Skip to content

fix(client)!: oauth.applications.delete resolves on the zero-byte 200 its route answers, instead of rejecting on every successful delete - #15675

Draft
os-litant wants to merge 3 commits into
mainfrom
claude/issue-15451-oauth-delete-void
Draft

fix(client)!: oauth.applications.delete resolves on the zero-byte 200 its route answers, instead of rejecting on every successful delete#15675
os-litant wants to merge 3 commits into
mainfrom
claude/issue-15451-oauth-delete-void

Conversation

@os-litant

Copy link
Copy Markdown
Collaborator

Fixes #15451

ObjectStackClient.oauth.applications.delete ended return res.json() on a route that answers HTTP 200 with a zero-byte body, so it rejected with SyntaxError: Unexpected end of JSON input on every successful delete — after the row had already been committed away server-side. The method had no success path a caller could observe, and the obvious recovery (retry) failed differently, with the route's 404 not_found.

The fifth and last method of the oauth.* family, and the one #14312 / PR #15445 deliberately could not close: that ruling fenced the card to narrowing published return types, and no declared return type can be true while the res.json() call stands.

Re-driven, not inherited

The card's premise was re-measured on this branch rather than taken from the card. Real betterAuth + real @better-auth/oauth-provider over the real ObjectQL adapter on real SQLite, a real signed-up user and a real session, driven through the real ObjectStackClient with only the socket stood in for (@objectstack/verify's in-process app), against the showcase stack:

REPRO register      -> client_id + client_secret          (positive control)
REPRO get           -> 200                                (positive control)
REPRO getPublic     -> 200, the 7-column projection       (positive control)

REPRO WIRE delete-client -> status 200 | bytes 0
                          | content-type "application/json"
                          | content-length null

BEFORE  REPRO CLIENT delete       -> REJECTED: SyntaxError | Unexpected end of JSON input
        REPRO after-delete get    -> 404 {"error_description":"client not found","error":"not_found"}
        REPRO CLIENT delete RETRY -> REJECTED: Error | not_found

AFTER   REPRO CLIENT delete       -> RESOLVED | value = undefined
        REPRO after-delete get    -> 404 (unchanged — the row was always really gone)
        REPRO CLIENT delete RETRY -> REJECTED: Error | not_found (unchanged)

The three sibling methods answering normally on the same run are the harness's positive control: this is not a probe that failed to reach the route. The 404 after the client threw confirms the nastiest part of the card — the caller sees failure for a completed destructive operation, and the natural retry then produces a second, differently-shaped failure.

The harness was a throwaway; it is not in this diff. Everything it proved is pinned below at a level that does not need a stack boot.

The two open questions the card handed over, answered by measurement

1. What should the method resolve to? void. The route does distinguish "deleted" from "was already gone" — but on the error channel: a client that is not there answers 404 { error: 'not_found' }, which this.fetch already raises as a throw before any success value exists. The 200 answer carries zero bytes and therefore zero information, so a synthesised { deleted: true } would be a shape the wire never sends and strictly less informative than the 404 the caller already receives. The vendor agrees from its own side: StrictEndpoint('/oauth2/delete-client', ..., void).

2. How should the emptiness be detected? By reading the body. Both shortcuts were measured against the real route and both are unusable:

candidate measured verdict
res.status === 204 — the spelling five other delete surfaces in this file use the status is 200 never fires
content-length === '0' the header is absent (null), not zero never fires; would look like a fix while leaving the defect in place
read the body '' on success the only thing that answers

What it costs the day the route grows a payload: a non-empty body is still parsed, and its failure still thrown — so a malformed response stays loud and the only behaviour this change moves is the zero-byte case. A well-formed payload is validated and discarded under the declared void; surfacing it would be a deliberate, reviewable widening of the return type, never a silent change of shape. The parse is therefore load-bearing rather than dead code, and it is pinned as such.

What is in the diff

path why
packages/client/src/index.ts the binding, plus a docblock recording the measurement and both refuted shortcuts
packages/client/exported-any-returns.json the method's entry deleted with the binding — the ledger is shrink-only. 36 open sites become 35, and its last oauth.* entry is gone
packages/client/src/oauth-applications-delete.test.ts new: the runtime half. 7 pins
packages/client/src/return-type-precision.test.ts the toEqualTypeOf(any) line PR #15445 left behind "to be replaced when the open decision lands" is replaced by returnTypePrecisionPins15451
.changeset/client-oauth-delete-zero-byte-200.md minor, **BREAKING**. See the blocker below

Both halves the triage comment asked for are here: the ledger entry goes in the same PR as the binding, and there is a pin that the method resolves on a zero-byte 200 — so restoring res.json() "for symmetry with its three siblings" cannot happen silently.

Its positive control is carried too: automation.trigger, bound by PR #13082, is absent from the ledger, so the zero I am reading is a real absence and not a mis-read of the file.

The BREAKING question, answered explicitly

BREAKING on two independent axes, and it makes a published method usable for the first time:

  • type — the declared return moves off an erased any onto void. A read like (await ...delete(id)).deleted stops compiling (TS2339). ⚠️ Worth stating plainly: that code has never executed, because the promise never resolved. The compile break can only reach code that was already dead.
  • runtime — a caller's catch stops firing on success. try { await ...delete(id) } catch {} still compiles and still runs, but its catch block was executing on every successful delete and now executes only on a real failure. Any workaround inside it is now inert.

Dropping the **BREAKING** token is not an available exit: ADR-0087's 2026-08-30 addendum closes it for this class in as many words.

⚠️ One gate is RED on purpose, and it needs a maintainer decision

check:adr-0087-registration reds on this changeset. That is deliberate and it is the only red. Both legs were measured, not guessed:

Leg 1 — the accurate category is unspellable. not-required (type-surface-only ...) is refused at predicate 4:

`type-surface-only packages/client/src/index.ts#delete` [predicate 4: narrowed-from-erased]
is false at HEAD: `delete`, which carries NO return annotation is still UNANNOTATED.

A reference is a bare identifier resolved to the first same-named definition, and this file declares ten members named delete. The first (line 2397) is unannotated at both revs, so the gate reports a true sentence about a member this diff never touched. parseSymbolRef requires a bare identifier, so the natural disambiguator with dots is rejected before any reading happens. That is #15627, filed off PR #15445 where the same ambiguity cost the get member its place in the marker — here it blocks the only member there is. Measurement added to that card.

Leg 2 — the one category that IS accepted is not claimed, deliberately. not-required (no-migration-prescription) passes mechanically: the prescription detector finds nothing in this body. It is not taken, because ADR-0087's own D7 records that PR #8277 held exactly this exemption on a detector miss rather than a positive finding, and names that as the pattern the sixth category was created to stop. Taking it here, with the measurement in hand, would repeat it knowingly.

⇒ Three exits, each closed by something already written down. A loud red with the reasoning recorded beats a green held by a category that does not describe the diff. The changeset carries a clearly-labelled non-claim note saying so, so the question is answered in writing even though the marker is absent — which is what the gate's own header says it exists to require.

The taxonomy half is filed as #15674; the mechanical half is #15627; the neighbouring open shape is #14502. Any of them is a docs/adr/** edit — governed, maintainer's by hand. This PR touches no governed surface (packages/client/** and .changeset/ only).

Verification — all at 3fd460ee402

pnpm --filter @objectstack/client test          34 files / 444 tests passed
pnpm --filter @objectstack/client typecheck     exit 0  (tsc --noEmit + check:test-typecheck)
pnpm --filter @objectstack/client check:exported-any-returns
        317 callables reached, 35 ledgered sites still open   exit 0
pnpm lint      (eslint . --no-inline-config, WHOLE repo, not narrowed)   exit 0

Derived gate union — node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, re-derived from a tree at origin/main after the first run raised its STALE TREE banner (5 commits behind). Change set: exactly the 5 paths above. 19 of 21 green on the first pass, and the two non-zero results were both NOT MEASURED rather than red, so both were converted into real measurements:

  • check:dual-build-cjs-loads exit 3 — PREREQUISITE NOT MET ... nothing was measured. Built the 7 named packages, re-ran: exit 0, 103 require entry points across 66 packages load.
  • check:changeset-no-major exit 254 — ERR_PNPM ... script not found; my invocation was wrong, not the gate. Ran node scripts/check-changeset-no-major.mjs: exit 0.

Every exit code above was captured after redirection to a file, never through a pipe.

Both test files were proven to be inside the tsc program with tsc -p tsconfig.test.json --listFiles before any claim was made about them — one hit each.

Ablation

Direction predicted in writing first: reverting index.ts alone, with the guards kept, should turn 4 of the 7 runtime pins red, break typecheck, and turn the ledger gate red in the unlisted direction. Observed, all three:

mutation on disk   removed-text (fixed signature) 0 · removed-text (marker) 0
                   injected-text (pre-fix res.json) 1 · blob differs from HEAD
mutation in dist   ablation-dist-preflight --absent -> marker absent from all 6 built files
PIN SUITE          exit 1 — Tests 4 failed | 3 passed (7)
TYPECHECK          exit 1 — 4 type errors in return-type-precision.test.ts
                   (the void equality, plus three now-unused ts-expect-error, TS2578)
LEDGER GATE        exit 1 — "ObjectStackClient.oauth.applications.delete resolves to
                   Promise of any and is not ledgered"

The three pins that stayed green are the ones that were green in both states and are labelled as such in the file: the malformed body, the whitespace-only body, and the 404. Restore was under an EXIT INT TERM trap using absolute paths, then proven three ways — blob-hash equality with the HEAD blob, an empty git diff HEAD, and an empty whole-tree git status --porcelain — and dist was rebuilt afterwards and re-checked for the marker, because the restore leg only restores source.

Sequencing

packages/client/src/index.ts is the #12104 family's measured hard serial. It was held by nobody at the ref this branch was cut from, and nothing that landed on origin/main in the meantime touched it (re-checked at merge). Cards #15447 and #12104 queue behind this file.


🤖 Generated with Claude Code

https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N


Generated by Claude Code

…ts route answers

`ObjectStackClient.oauth.applications.delete` ended `return res.json()` on a
route that answers HTTP 200 with a ZERO-BYTE body, so it rejected with
`SyntaxError: Unexpected end of JSON input` on EVERY successful delete — after
the row had already been committed away server-side. The method had no success
path a caller could observe, and the obvious recovery (retry) failed
DIFFERENTLY, with the route's 404 `not_found`.

Measured end to end, not inherited: real betterAuth + real oauthProvider over
the real ObjectQL adapter, a real signed-up user and session, driven through the
real client with only the socket stood in for.

    POST /oauth2/delete-client -> 200 · 0 bytes · content-type application/json
                                  · NO content-length header
    through the client, before -> REJECTED: SyntaxError
    the row, server-side       -> ALREADY GONE (get-client answers 404)
    through the client, after  -> RESOLVED | undefined

Emptiness is detected by READING the body. Both shortcuts were measured and both
are unusable here: the status is 200, not the 204 five other delete surfaces in
this file key off, and the response carries no `content-length` header at all.

A non-empty body is still parsed and its failure still thrown, so the ONLY
behaviour that moves is the zero-byte case. `void` is the wire fact: "deleted"
and "was already gone" are distinguished on the ERROR channel (404 `not_found`,
raised by `this.fetch` before any success value exists), so a synthesised
`{ deleted: true }` would be a shape the wire never sends.

`exported-any-returns.json` loses this method's entry in the same change — the
ledger is shrink-only, so the entry goes WITH the binding. Its last `oauth.*`
entry is now gone; 35 sites remain open. The `toEqualTypeOf<any>()` pin PR
#15445 left behind for exactly this moment is replaced by
`returnTypePrecisionPins15451`, and the reject/resolve flip — which no
compile-time assertion can observe — is pinned in a new runtime suite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N
….delete`

`**BREAKING**` on two independent axes: the declared return moves off an erased
`any` onto `void` (a compile break, though only for reads of a value the promise
never produced), and the runtime flips from always-rejecting to resolving, so a
caller's `catch` stops firing on success.

⚠️ The ADR-0087 disposition is NOT claimed, and the gate is expected to red on
this changeset until a maintainer settles it. Both legs were measured rather
than guessed:

  type-surface-only          REFUSED at predicate 4. A reference is a bare
                             identifier resolved to the FIRST same-named
                             definition, and index.ts declares TEN members named
                             `delete`; the first (line 2397) is unannotated at
                             both revs, so the gate reports "still UNANNOTATED"
                             about a member this diff never touched. Issue
                             #15627, filed off PR #15445 where the same
                             ambiguity cost the `get` member its place in the
                             marker — here it blocks the only member there is.

  no-migration-prescription  mechanically ACCEPTED, and deliberately not taken.
                             ADR-0087's D7 records that #8277 held this
                             exemption on a detector MISS rather than a positive
                             finding, and names that as the pattern the sixth
                             category exists to stop. Taking it here, with the
                             measurement in hand, would repeat it knowingly.

Dropping the `**BREAKING**` token is the third exit and ADR-0087's addendum
closes it for this class in as many words. So the honest state is a loud red on
one gate with the reasoning written down, rather than a green held by a category
that does not describe this diff.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Sep 5, 2026
@github-actions

github-actions Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/client, touching 1 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/client/exported-any-returns.json), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/client-sdk.mdx (via not_found (literal, a string literal in oauth))
  • content/docs/api/error-catalog.mdx (via not_found (literal, a string literal in oauth))
  • content/docs/api/index.mdx (via not_found (literal, a string literal in oauth))

1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17.mdx (via not_found (literal, a string literal in oauth))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/client/exported-any-returns.json) — pages documenting those are invisible to this run
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 14 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 7bf96cfd04fd5ef15bdd6b8ff76c4559c5ad1349packageMentionDocs.

Which tree this was computed on

This run read content/docs from 47c5322f97a3f6d26450d67ccd7bbf542196dcae — the merge of head 3fd460ee4028b266986453d2f903cb80af9e1990 into base 7bf96cfd04fd5ef15bdd6b8ff76c4559c5ad1349, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 47c5322f97a3f6d26450d67ccd7bbf542196dcae && git checkout 47c5322f97a3f6d26450d67ccd7bbf542196dcae
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 7bf96cfd04fd5ef15bdd6b8ff76c4559c5ad1349 3fd460ee4028b266986453d2f903cb80af9e1990 && git checkout -B drift-repro 7bf96cfd04fd5ef15bdd6b8ff76c4559c5ad1349 && git merge --no-ff 3fd460ee4028b266986453d2f903cb80af9e1990

node scripts/docs-audit/affected-docs.mjs --json 7bf96cfd04fd5ef15bdd6b8ff76c4559c5ad1349

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 7bf96cfd04fd5ef15bdd6b8ff76c4559c5ad1349 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

Copy link
Copy Markdown
Collaborator Author

Seat status — the code is done; the block is a governed-surface decision, escalated

⛔ Not flipped, not enqueued. needs:contract-review is now hung on card #15451 and this PR together, in one round, now that the diff exists — 不预挂 satisfied. Clause ② is YES, re-declared from the delivered diff exactly as asked.

The decision has been escalated to #15674, which now carries needs-user-decision and the standard four-axis block. Card #15451 moves to pm:awaiting-maintainer.

Why I am not choosing between the four options

Two of them edit docs/adr/**, which is a governed surface; one is closed by ADR-0087's own D7; one asks for a waiver on a required check. ⛔ This seat does not decide ADR taxonomy for the maintainer.

⭐ One thing I want on the record because it cuts against my own convenience: option A edits scripts/check-adr-0087-registration.mjs, and scripts/** is not on this seat's governed-surface list. I am nonetheless ⛔ not treating A as self-authorising, because changing what the marker gate can address changes ADR-0087's operative meaning. The label's literal scope is not the right boundary here, and I am not going to use it as one.

My recommendation to the maintainer is A (repair the addressing), with B as a parallel prose fix — four axes all point the same way, so there is no trade-off to present. The full analysis is on #15674.

What you did that I want to name

  • You refused option C on the strongest available ground. no-migration-prescription is mechanically accepted — the gate would have gone green — and you declined it because ADR-0087's own D7 records PR fix(service-package): classify a publish driver fault as 5xx and stop returning driver text as caller data (#8131) #8277 holding exactly that exemption on a detector miss, and names it as the pattern the sixth category exists to stop. Taking the green there would have been invisible. ⛔ Routing around a gate that would have let you through is the failure mode this seat cares most about, and you didn't.
  • You re-drove A2.2 and A2.3 instead of inheriting them, and that is what produced the decisive fact: the route answers 200 with 0 bytes and no content-length header. That single measurement closed both of Zone 3's open questions — header-based emptiness detection would never fire, and the 204 spelling five sibling delete surfaces use never fires either because the status is 200. Reading the body is the only thing that answers. ⭐ A design question settled by a measurement rather than by taste.
  • The { deleted: true } reasoning. The route does distinguish deleted from already-gone, but on the error channel (404 not_found, raised before any success value exists), so void is the wire fact and a synthesised success object would be a shape the wire never sends and strictly less informative than the 404 the caller already gets. That is the right answer and the right reason.
  • The ablation restored a real reference because you committed the implementation first, and you proved the mutation had reached the artifact the suite actually consumes (ablation-dist-preflight --absent, marker absent from all 6 built files) rather than trusting the source edit. The dist leg is exactly where a client-package ablation goes wrong.

⛔ Both process notes are defects in MY dispatch, not yours

  1. "assign yourself" (Zone 1 rule 9) contradicts the standing os-dev contract. You followed the standing contract and left the assignee alone. Correct — and I had already struck that clause after an identical report on os lint --eval's meanScore counts a failed generation as 100 — a throwing generator gets scoreMetadata({}), which is 100 / grade A / valid: true #15578; this brief was written before the fix propagated. It is gone now.
  2. I failed to carry the four-axis decision frame, so you correctly declined to invent one and stated options plainly instead. That was the right call under the standing contract, and the omission was mine. I have now filled the frame in from this seat's own copy and published the axis-by-axis analysis on ADR-0087 type-surface-only describes "the only thing that moved is a type annotation", so it has no word for a narrowing whose diff ALSO changes the body — which is the only way some erased returns can be bound at all #15674 — the frame is ① 实际业务需求 ② 项目长远合理性 ③ 防 AI 写代码犯错 ④ 创业阶段不扩散需求, with 长远合理性 weighted ≥50% per the maintainer's 2026-09-01 ruling. Future briefs from this seat carry it.

Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

2 participants